iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 3

Day 3|消失的 IoC 控制權:為什麼手動 new Service() 會繞過 NestJS 的 DI?

  • 分享至 

  • xImage
  •  

昨天我們靠著釐清 providersexportsimports 找回了消失的 provider。但在剛接觸 NestJS 時,有些人可能會有一個直覺想法:

「既然最後都是要拿 PostsService 的實例,我在 Controller 直接 new 一個出來用,不也一樣嗎?」

const postsService = new PostsService();

程式碼可以運行,方法也能順利呼叫。乍看之下,自己 new 和交給 NestJS 建立結果相同,但這其實踩進了另一個坑。

手動建立的實例就像是「逃離了容器管轄的黑戶」——它雖然能工作,卻完全脫離了 NestJS IoC 容器。生命週期鉤子(Lifecycle Hooks)、作用域(Scopes)、依賴自動組裝,甚至是單元測試的 Mock 覆寫,全都會在這一行 new 中失效。

今天我們要拆解的核心觀念是:原本應由 NestJS 管理的 Service,不該由使用端自行 new 出來。

先釐清 DI、IoC 與 IoC Container

這三個名詞常常一起出現,但負責的事情不同。

https://ithelp.ithome.com.tw/upload/images/20260917/20184306NpSfhao0Vq.png

DI(Dependency Injection,依賴注入)

核心概念:物件所需的依賴由外部提供,而不是由物件自己建立。

PostsService 為例,它需要 PostIdGeneratorService 才能產生文章編號。我們不在內部自行初始化,而是透過建構子宣告需求:

export class PostsService {
  constructor(
    private readonly postIdGeneratorService: PostIdGeneratorService,
  ) {}
}

PostsService 只負責宣告「我要什麼」,這就是 DI。不過,光是這樣還不夠——到底由誰建立並傳入 PostIdGeneratorService

IoC(Inversion of Control,控制反轉)

核心概念:把物件建立的控制權交給外部框架

如果呼叫端自行組裝:

const generator = new PostIdGeneratorService();
const postsService = new PostsService(generator);

呼叫端必須決定:

  1. 選用哪個具體類別。
  2. 何時建立物件。
  3. 如何連接依賴。

IoC 則是把這些控制權交給框架,Controller 只需要宣告:

constructor(private readonly postsService: PostsService) {}

至於 PostsService 和它的依賴如何建立的細節,我們可以不用知道。

IoC Container(Inversion of Control Container,控制反轉容器)

核心概念:實現控制反轉的工具或框架,負責建立物件、管理依賴關係,並在需要時注入依賴。

Nest 內建的 IoC 容器會在背後做這些:

  • 解析 Metadata:讀取 TypeScript 型別與注入 Token。
  • 搜尋 Provider:在模組可見範圍內找出相符的依賴。
  • 自動組裝:依照依賴順序建立並注入實例。
  • 生命週期管轄:控制 Scope(如 Singleton)並呼叫 Lifecycle Hooks。

當模組註冊:

@Module({
  controllers: [PostsController],
  providers: [PostIdGeneratorService, PostsService],
})
export class PostsModule {}

Nest 建立 PostsService 時,概念上等同於做了這件事:

const generator = container.getOrCreate(PostIdGeneratorService);
const postsService = new PostsService(generator);

採用 DI 容器並不代表依賴憑空消失了,而是框架在背後替我們解析並自動完成了傳入與組裝。

問題怎麼發生?

接下來用一個簡單的範例,看看「手動建立」和「容器管理」到底差在哪。

範例由兩個 Service 組成,PostsService 負責建立文章,而 PostIdGeneratorService 負責產生文章的流水編號。

先來看 PostIdGeneratorService

@Injectable()
export class PostIdGeneratorService {
  private sequence = 0;

  next(): string {
    this.sequence += 1;
    return `post-${this.sequence}`;
  }
}

PostsService 透過建構子取得編號產生器:

@Injectable()
export class PostsService implements OnModuleInit {
  private initializedByNest = false;

  constructor(
    private readonly postIdGeneratorService: PostIdGeneratorService,
  ) {}

  onModuleInit(): void {
    this.initializedByNest = true;
  }

  create(title: string) {
    return {
      id: this.postIdGeneratorService.next(),
      title,
      initializedByNest: this.initializedByNest, // 用來觀察生命週期
    };
  }
}

在 NestJS 預設的 Singleton 作用域下,容器會在模組依賴解析完成後自動觸發 onModuleInit();而我們手動 new 出來的實例,則不會觸發這套流程。

接著在 Controller 建立兩個端點,分別用兩種方式取得 PostsService

@Controller('posts')
export class PostsController {
  // 手動建立 PostsService,並自行提供它需要的依賴
  private readonly manualPostsService = new PostsService(
    new PostIdGeneratorService(),
  );

  constructor(private readonly postsService: PostsService) {}

  @Post('manual')
  createManually(@Body() body: { title: string }) {
    return this.manualPostsService.create(body.title);
  }

  @Post('injected')
  createWithInjection(@Body() body: { title: string }) {
    return this.postsService.create(body.title);
  }
}

手動版本中,Controller 同時建立 PostsService 與它的依賴;Nest 注入版本則把這些建立與組裝工作交給容器。

實測對比

啟動範例後,先呼叫手動建立的版本:

curl -X POST http://localhost:3000/posts/manual \
  -H 'Content-Type: application/json' \
  -d '{"title":"NestJS DI"}'
{
  "id": "post-1",
  "title": "NestJS DI",
  "initializedByNest": false
}

再呼叫注入版本:

curl -X POST http://localhost:3000/posts/injected \
  -H 'Content-Type: application/json' \
  -d '{"title":"NestJS DI"}'
{
  "id": "post-1",
  "title": "NestJS DI",
  "initializedByNest": true
}

兩個端點都從 post-1 開始,但 initializedByNest 一個是 false、一個是 true。雖然使用的是相同類別,實際上卻來自兩組彼此獨立的實例:

PostsController
├── Nest 注入的 PostsService
│   └── Nest 管理的 PostIdGeneratorService
└── 手動 new 的 PostsService
    └── 手動 new 的 PostIdGeneratorService

根因:手動 new 讓物件圖脫離 IoC 容器

為什麼兩個端點無法共用同一套狀態?根本原因在於 new PostsService(...) 這行程式碼,強制將物件的建立權從 Nest 容器手中奪了回來。

一旦實例被擺脫容器監管,就會產生這四個後果:

1. 整棵依賴樹(Dependency Tree)變成呼叫端的責任

現在 PostsService 只有一個依賴,所以手動組裝看起來還不嚴重:

new PostsService(new PostIdGeneratorService());

如果未來再加入設定與 Repository:

constructor(
  private readonly postIdGeneratorService: PostIdGeneratorService,
  private readonly postsRepository: PostsRepository,
) {}

所有建立細節都會回到 Controller。Service 每增加一個依賴,Controller 就得跟著修改,原本應由容器封裝的組裝規則也散落進業務程式碼。

2. singleton 只約束容器管理的實例

Nest 的 provider 預設使用 singleton scope。在同一個應用程式生命週期中,使用端通常會取得容器管理的同一個實例。

但 singleton 只約束容器管理的範圍。這行程式仍然可以建立額外實例:

new PostIdGeneratorService();

所以兩個端點各自擁有一份 sequence,才會同時從 post-1 開始。這不是 Nest 建立了兩個 singleton,而是其中一個實例根本不屬於 Nest。

3. lifecycle hook 不會自動執行

onModuleInit() 會在所在模組的依賴解析完成後,由 Nest 對容器管理的物件呼叫。

如果你選擇手動初始化實例:

const service = new PostsService(new PostIdGeneratorService());

這只會執行 JavaScript 建構子,即使類別實作 OnModuleInit,也不會自動呼叫 onModuleInit()

雖然你可以手動寫:

service.onModuleInit();

但這只是呼叫一個普通方法,不代表該實例已經進入 Nest 容器。

4. 單元測試的 Provider 覆寫機制無效化

測試可以覆寫容器裡的 provider:

const moduleFixture = await Test.createTestingModule({
  imports: [PostsModule],
})
  .overrideProvider(PostIdGeneratorService)
  .useValue({ next: () => 'test-post-id' })
  .compile();

結果是:

端點 回傳 ID 原因
/posts/injected test-post-id 編號產生器由容器提供,成功被覆寫
/posts/manual post-1 Controller 自行建立依賴,TestingModule 不知道它存在

overrideProvider() 並沒有失效,它只會對容器知道的 provider 作用。

從流水號重複(單例失效)、生命週期沒觸發(Lifecycle 沉寂),到單元測試無法被 Mock,這些現象並不是幾個互不相干的 Bug。它們都是同一個根本原因造成的:手動 new 讓物件脫離了 IoC 容器。

排雷指南:把建立物件的責任交還給容器

要徹底解決這個問題,重點在回歸 DI 的本質:由模組註冊 provider,使用端只負責宣告需求。

@Module({
  controllers: [PostsController],
  providers: [PostIdGeneratorService, PostsService],
})
export class PostsModule {}

Controller 只保留建構子注入:

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Post()
  create(@Body() body: { title: string }) {
    return this.postsService.create(body.title);
  }
}

現在 Controller 不需要知道 PostsService 有幾個依賴、使用哪個具體實作,或何時執行 lifecycle hook。這些組裝規則都留在容器管理的邊界。

我們在實務開發中還是會遇到一些特殊情況,以下整理三種常見的情境與解法:

情境一:什麼時候可以使用 new

NestJS 沒有禁止 new。一般的 DTO、Entity、Value Object,或沒有外部依賴的區域性純工具,仍然可以依照設計自行建立。

但如果物件具有以下任一特徵,通常就適合交給容器:

  • 建構子需要注入其他 provider。
  • 需要集中設定或替換具體實作。
  • 需要 singleton、request 或 transient scope 等作用域管理。
  • 使用 lifecycle hook。
  • 希望在測試中透過 overrideProvider() 替換。
  • 會被多個 Controller 或 Service 共用。

判斷重點不是名稱裡有沒有 Service,而是它是否需要容器提供的管理能力。

情境二:需要依設定選擇實作——使用 custom provider

有時候我們需要根據設定選擇不同的實作,例如文章編號在某些環境使用 UUID,另一些環境使用流水號。這種情況可以使用 token 搭配 factory provider,把選擇實作的責任交給容器。

先定義共同介面與注入 token:

export const POST_ID_GENERATOR = Symbol('POST_ID_GENERATOR');

export interface PostIdGenerator {
  next(): string;
}

再提供兩個實作:

@Injectable()
export class UuidPostIdGenerator implements PostIdGenerator {
  next(): string {
    return crypto.randomUUID();
  }
}

@Injectable()
export class SequentialPostIdGenerator implements PostIdGenerator {
  private sequence = 0;

  next(): string {
    this.sequence += 1;
    return `post-${this.sequence}`;
  }
}

接著把兩個實作都交給 Nest 管理,再由 factory 根據設定選擇:

@Module({
  imports: [ConfigModule],
  providers: [
    UuidPostIdGenerator,
    SequentialPostIdGenerator,
    {
      provide: POST_ID_GENERATOR,
      inject: [
        ConfigService,
        UuidPostIdGenerator,
        SequentialPostIdGenerator,
      ],
      useFactory: (
        configService: ConfigService,
        uuidGenerator: UuidPostIdGenerator,
        sequentialGenerator: SequentialPostIdGenerator,
      ): PostIdGenerator => {
        return configService.get('POST_ID_TYPE') === 'uuid'
          ? uuidGenerator
          : sequentialGenerator;
      },
    },
    CustomProviderPostsService,
  ],
})
export class CustomProviderModule {}

使用端只需要注入 token,不必知道實際使用哪個實作:

@Injectable()
export class CustomProviderPostsService {
  constructor(
    @Inject(POST_ID_GENERATOR)
    private readonly postIdGenerator: PostIdGenerator,
  ) {}

  create(title: string) {
    return {
      id: this.postIdGenerator.next(),
      title,
    };
  }
}

這裡的重點是:具體實作仍然由 Nest 建立與管理,factory 只負責選擇要使用哪一個。

因此,即使未來某個實作新增其他依賴,也不需要在 factory 裡手動 new 並組裝整棵依賴樹。

如果實作在應用啟動時就能決定,custom provider 通常已經足夠;只有需要在執行期間動態取得 provider 時,才需要考慮 ModuleRef

情境三:真的要在執行期查找 provider——使用 ModuleRef

若具體實作必須等到方法執行時才能決定,可以注入 ModuleRef,從容器取得已註冊的 provider:

import { ModuleRef } from '@nestjs/core';

@Injectable()
export class PostPublisher {
  constructor(private readonly moduleRef: ModuleRef) {}

  publish(mode: 'draft' | 'public') {
    const publisher =
      mode === 'draft'
        ? this.moduleRef.get(DraftPostPublisher)
        : this.moduleRef.get(PublicPostPublisher);

    return publisher.publish();
  }
}

get() 適合取得已註冊並存在於靜態模組範圍的 provider。若是 REQUESTTRANSIENT scope,則需要使用非同步的 resolve()

const service = await this.moduleRef.resolve(ScopedService);

需要沿用目前請求的 request scope 時,還必須傳入對應的 context ID。這屬於較進階的作用域管理,這邊就不做更深入的說明了。

特別注意 ModuleRef 是特殊情況下的動態查找工具,不是一般建構子注入的替代品。濫用它會把依賴藏進方法內,讓類別的依賴關係變得不明顯;能在建構子明確宣告的依賴,仍應優先使用建構子注入。

陷阱:以為 @Injectable() 會攔截 new

看到裝飾器,很容易以為只要類別標上 @Injectable(),任何建立方式都會自動連上 Nest:

@Injectable()
export class PostsService {}

@Injectable() 不會改寫 JavaScript 的 new,也不會在每次建立實例時通知容器。

它留下讓 Nest 解析建構子依賴所需的 metadata,表示這個類別可以交給 IoC 容器管理。真正讓 DI 生效仍有兩個前提:

  1. provider 已註冊在有效的模組範圍。
  2. 實例由 Nest 容器建立或提供。

因此:

const fromContainer = app.get(PostsService);
const createdManually = new PostsService(new PostIdGeneratorService());

兩者來自相同類別,卻不是相同實例,也不具有相同的管理方式。

總結

  1. DI、IoC 與 IoC Container 關注的層次不同:DI 讓物件的依賴由外部提供;IoC 把建立與組裝物件的控制權交出去;IoC Container 則負責實際解析、建立與管理這些物件。
  2. 手動 new 會建立一棵容器外的物件圖:這些實例即使能正常運作,也不會自動套用 Nest 提供的作用域、lifecycle hook 與測試替換等管理能力。
  3. 需要切換實作時,不必放棄 DI:啟動時就能決定的實作,可以使用 custom provider;只有需要在執行期間動態取得 provider 時,才考慮 ModuleRef
  4. @Injectable() 不會攔截 JavaScript 的 new:它提供 Nest 解析與管理類別所需的 metadata,但真正讓 DI 生效的關鍵,仍然是讓實例進入 Nest 容器的管理流程。

參考資料


上一篇
Day 2|消失的提供者:為什麼寫了 Service 卻噴 can't resolve dependencies?
下一篇
Day 4|錯位的參數:useFactory 的 inject 陣列寫錯,為什麼傳進來的依賴全對不上?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言